You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
291 lines
7.9 KiB
291 lines
7.9 KiB
<script setup lang="ts">
|
|
import { request, unwrapApiBody, type ApiResponse } from '../../../utils/http/factory'
|
|
import { useAuthSession } from '../../../composables/useAuthSession'
|
|
|
|
definePageMeta({ title: '编辑文章' })
|
|
|
|
type PostRow = { id: number; title: string; slug: string; visibility: string }
|
|
|
|
const route = useRoute()
|
|
const id = computed(() => route.params.id as string)
|
|
const { user, refresh: refreshAuth } = useAuthSession()
|
|
|
|
const state = reactive({
|
|
title: '',
|
|
slug: '',
|
|
excerpt: '',
|
|
bodyMarkdown: '',
|
|
visibility: 'private',
|
|
shareToken: '' as string | null,
|
|
})
|
|
const loading = ref(true)
|
|
const saving = ref(false)
|
|
const postsNav = ref<PostRow[]>([])
|
|
const jumpIdRaw = ref('')
|
|
const toast = useToast()
|
|
|
|
const currentNumericId = computed(() => Number.parseInt(id.value, 10))
|
|
|
|
const newerPost = computed((): PostRow | null => {
|
|
const list = postsNav.value
|
|
const idx = list.findIndex((p) => p.id === currentNumericId.value)
|
|
if (idx <= 0) {
|
|
return null
|
|
}
|
|
return list[idx - 1] ?? null
|
|
})
|
|
|
|
const olderPost = computed((): PostRow | null => {
|
|
const list = postsNav.value
|
|
const idx = list.findIndex((p) => p.id === currentNumericId.value)
|
|
if (idx < 0 || idx >= list.length - 1) {
|
|
return null
|
|
}
|
|
return list[idx + 1] ?? null
|
|
})
|
|
|
|
const publicPostHref = computed(() => {
|
|
const ps = user.value?.publicSlug
|
|
if (state.visibility !== 'public' || !ps || !state.slug) {
|
|
return ''
|
|
}
|
|
return `/@${ps}/posts/${encodeURIComponent(state.slug)}`
|
|
})
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
try {
|
|
const res = await request<ApiResponse<{ post: typeof state }>>(`/api/me/posts/${id.value}`)
|
|
const p = unwrapApiBody(res).post
|
|
Object.assign(state, {
|
|
title: p.title,
|
|
slug: p.slug,
|
|
excerpt: p.excerpt,
|
|
bodyMarkdown: p.bodyMarkdown,
|
|
visibility: p.visibility,
|
|
shareToken: p.shareToken ?? null,
|
|
})
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadPostNav() {
|
|
try {
|
|
const res = await request<ApiResponse<{ posts: PostRow[] }>>('/api/me/posts')
|
|
postsNav.value = unwrapApiBody(res).posts
|
|
} catch {
|
|
postsNav.value = []
|
|
}
|
|
}
|
|
|
|
function goJumpId() {
|
|
const n = Number.parseInt(jumpIdRaw.value.trim(), 10)
|
|
if (!Number.isFinite(n) || n < 1) {
|
|
toast.add({ title: '请输入有效的文章 ID', color: 'error' })
|
|
return
|
|
}
|
|
if (n === currentNumericId.value) {
|
|
return
|
|
}
|
|
void navigateTo(`/me/posts/${n}`)
|
|
}
|
|
|
|
onMounted(() => {
|
|
void refreshAuth(true)
|
|
void loadPostNav()
|
|
void load()
|
|
})
|
|
|
|
watch(id, () => {
|
|
void load()
|
|
})
|
|
|
|
async function save() {
|
|
saving.value = true
|
|
try {
|
|
await request(`/api/me/posts/${id.value}`, {
|
|
method: 'PUT',
|
|
body: {
|
|
title: state.title,
|
|
slug: state.slug,
|
|
excerpt: state.excerpt,
|
|
bodyMarkdown: state.bodyMarkdown,
|
|
visibility: state.visibility,
|
|
},
|
|
})
|
|
await load()
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function remove() {
|
|
await request(`/api/me/posts/${id.value}`, { method: 'DELETE' })
|
|
await navigateTo('/me/posts')
|
|
}
|
|
|
|
const shareUrl = computed(() => {
|
|
const slug = user.value?.publicSlug
|
|
if (state.visibility !== 'unlisted' || !state.shareToken || !slug) {
|
|
return ''
|
|
}
|
|
if (import.meta.client) {
|
|
return `${window.location.origin}/p/${slug}/t/${state.shareToken}`
|
|
}
|
|
return ''
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<UContainer class="py-8 max-w-6xl space-y-6">
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<h1 class="text-2xl font-semibold tracking-tight">
|
|
编辑文章
|
|
</h1>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UButton
|
|
v-if="publicPostHref"
|
|
:to="publicPostHref"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
variant="soft"
|
|
color="neutral"
|
|
size="sm"
|
|
>
|
|
公开页
|
|
</UButton>
|
|
<UButton to="/me/posts" variant="ghost" color="neutral" size="sm">
|
|
返回列表
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
|
|
<UCard v-if="!loading" :ui="{ body: 'p-3 sm:p-4' }">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UButton
|
|
v-if="newerPost"
|
|
:to="`/me/posts/${newerPost.id}`"
|
|
variant="soft"
|
|
color="neutral"
|
|
size="sm"
|
|
leading-icon="i-lucide-chevron-up"
|
|
>
|
|
较新
|
|
</UButton>
|
|
<UButton
|
|
v-else
|
|
disabled
|
|
variant="soft"
|
|
color="neutral"
|
|
size="sm"
|
|
leading-icon="i-lucide-chevron-up"
|
|
>
|
|
较新
|
|
</UButton>
|
|
<UButton
|
|
v-if="olderPost"
|
|
:to="`/me/posts/${olderPost.id}`"
|
|
variant="soft"
|
|
color="neutral"
|
|
size="sm"
|
|
leading-icon="i-lucide-chevron-down"
|
|
>
|
|
较旧
|
|
</UButton>
|
|
<UButton
|
|
v-else
|
|
disabled
|
|
variant="soft"
|
|
color="neutral"
|
|
size="sm"
|
|
leading-icon="i-lucide-chevron-down"
|
|
>
|
|
较旧
|
|
</UButton>
|
|
<div class="flex flex-1 flex-wrap items-center gap-2 min-w-[12rem] justify-end">
|
|
<UInput
|
|
v-model="jumpIdRaw"
|
|
type="text"
|
|
size="sm"
|
|
placeholder="文章 ID"
|
|
class="w-28"
|
|
autocomplete="off"
|
|
@keydown.enter.prevent="goJumpId"
|
|
/>
|
|
<UButton size="sm" color="neutral" @click="goJumpId">
|
|
跳转
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
<p v-if="newerPost || olderPost" class="text-xs text-muted mt-2">
|
|
顺序与列表一致:最新在上(<code class="text-xs">/me/posts</code>)。
|
|
</p>
|
|
</UCard>
|
|
|
|
<div v-if="loading" class="text-muted">
|
|
加载中…
|
|
</div>
|
|
|
|
<template v-else>
|
|
<UForm :state="state" class="space-y-6" @submit.prevent="save">
|
|
<UCard :ui="{ body: 'p-4 sm:p-6' }">
|
|
<PostBodyMarkdownEditor v-model="state.bodyMarkdown" />
|
|
<UFormField label="正文" name="bodyMarkdown" required class="sr-only" />
|
|
</UCard>
|
|
|
|
<UCard :ui="{ body: 'p-4 sm:p-6 space-y-4' }">
|
|
<UCollapsible :unmount-on-hide="false">
|
|
<UButton
|
|
type="button"
|
|
color="neutral"
|
|
variant="subtle"
|
|
block
|
|
class="justify-between font-medium"
|
|
label="文章设置"
|
|
trailing
|
|
trailing-icon="i-lucide-chevron-down"
|
|
/>
|
|
<template #content>
|
|
<div class="pt-4 space-y-4 border-t border-default mt-4">
|
|
<UAlert
|
|
v-if="shareUrl"
|
|
title="仅链接分享"
|
|
:description="shareUrl"
|
|
/>
|
|
<UFormField label="标题" name="title" required>
|
|
<UInput v-model="state.title" />
|
|
</UFormField>
|
|
<UFormField label="slug" name="slug" required>
|
|
<UInput v-model="state.slug" />
|
|
</UFormField>
|
|
<UFormField label="摘要" name="excerpt" required>
|
|
<UInput v-model="state.excerpt" />
|
|
</UFormField>
|
|
<UFormField label="可见性" name="visibility">
|
|
<USelect
|
|
v-model="state.visibility"
|
|
:items="[
|
|
{ label: '私密', value: 'private' },
|
|
{ label: '公开', value: 'public' },
|
|
{ label: '仅链接', value: 'unlisted' },
|
|
]"
|
|
/>
|
|
</UFormField>
|
|
</div>
|
|
</template>
|
|
</UCollapsible>
|
|
</UCard>
|
|
|
|
<div class="flex flex-wrap gap-2">
|
|
<UButton type="submit" :loading="saving">
|
|
保存
|
|
</UButton>
|
|
<UButton color="error" variant="soft" type="button" @click="remove">
|
|
删除
|
|
</UButton>
|
|
</div>
|
|
</UForm>
|
|
</template>
|
|
</UContainer>
|
|
</template>
|
|
|